Next.js의 SSG를 활용한 마크다운 페이지 렌더링 자동화

기능구현
2026년 2월 20일
/Projects/Dev-library/Next.js의 SSG를 활용한 마크다운 페이지 렌더링 자동화
목차
1. 목표
2. dynamic 라우팅
3. generateStaticParams를 활용한 라우팅 제한
4. 마크다운 경로 기반 Slug 생성
4.1. 로컬 경로에 있는 마크다운 파일 읽어오기
4.2. slug 및 markdown front 파싱
4.2.1. 최종적으로 만들 형태
4.2.2. slug 생성
4.2.3. front 파싱
4.3. 데이터 기반 slug 생성
4.4. 페이지 내에서 정보 가져오기
5. 경로기반 트리구조 자동 생성
5.1. 중간 노드
5.2. 잎사귀 노드(마지막 노드)
5.3. 코드로 보기
6. 아쉬운점

목표

obsidian에 저장된 노트들을 빌드 시 자동으로 파싱하여 좌측에 트리구조를 형성하고, 각 leaf노드를 클릭했을 때 페이지별로 마크다운 문서를 렌더링하는 구조 작성.

아래처럼 obsidian 트리 구조가 있다고 가정했을 때

no image

블로그 웹사이트에서 폴더 구조를 그대로 반영하여 렌더링하고, leaf노드 클릭 시 해당 페이지를 렌더링하는 방법을 알아보자.

no image

dynamic 라우팅

우선 페이지 라우팅에 대해 생각해보면
obsidian의 폴더가 다음과 같이 되어있을 때

test/
	폴더1/
		a.md
		b.md
	폴더2/
		c.md
		폴더3/
			d.md	
	e.md
	f.md

nextjs에서 다음과 같은 path를 기반으로 마크다운을 라우팅하도록 구성했다.

  • /blog/test/폴더1/a
  • /blog/test/폴더1/b
  • /blog/test/폴더2/c
  • /blog/test/폴더1/폴더3/d
  • /blog/test/e
  • /blog/test/f

이를 구현하기 위해 next.js의 동적 라우팅을 적용했다. 적용하는 방법은 아래 공식문서에서 확인 가능하다
nextjs dynamic routes 한글 공식문서

no image

%% page.tsx %%

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;

next.js에서는 catch all segments라는 방식을 통해 [...slugs] 이런식으로 경로를 설정하면 뒤에 오는 모든 경로를 매칭시킬 수 있으며, 각 페이지에서 slug를 비동기로 받아와서 사용할 수 있다.
/blog/a, /blog/folder1/a, /blog/test1/test2/f312

generateStaticParams를 활용한 라우팅 제한

Catch all segments 방식에서는 모든 라우팅을 허용하기 때문에, 마크다운 경로를 제외한 모든 경로를 차단해야한다. 직접 허용해준 경로 외에 모두 차단하는 방식은 다음과 같다.

export function generateStaticParams() {
  return [{ slug: ["1"] }, { slug: ["2"] }];
}

export const dynamicParams = false;

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;

  return <div>blog {slug}</div>;
}

dynamicParams = false 를 적용하면 generateStaticParams에서 만들어준 slug를 제외하고, 전부 404페이지로 유도한다.

  • 적용 안했을 때
    no image

4라는 slug로 접근해도 허용해 준다.

  • 적용했을 때
    no image

현재 1과 2만 허용해줬으므로 4로 접근하면 404페이지가 뜬다.

마크다운 경로 기반 Slug 생성

이제 접근제한도 잘 확인했으니, 이제 실제 마크다운 경로들을 가져와 slug로 만들어줘야 한다.

로컬 경로에 있는 마크다운 파일 읽어오기

현재 프로젝트 구조는 이렇다.

/project
	/src
		/app
		...
		
	/contents 
		/test
			...가져올 마크다운 파일들
		/folder2
		/folder3
			

obsidian vault(옵시디언에서의 폴더)인 contents에서 fetchPosts('/test') 처럼 함수를 실행하면, 원하는 폴더만 콕 집어서 가져오는 방식을 생각했다.

const fetchPosts = async (contentPath: string) => {
  const projectPath = process.cwd();

  // Get contents from submodule
  const fullPath = `${projectPath}/contents${contentPath}`;
  
  // Get Markdown file paths
  const mdFiles = await glob(`${fullPath}/**/*.md`);

  console.log(mdFiles);
  
  ...
  • 현재 프로젝트 경로를 기반으로 마크다운 파일들이 저장된 vault 폴더 경로를 가져온다.
  • glob 라이브러리를 통해 md파일을 전부 읽어와서 출력한다.

no image
이런식으로 경로에서 마크다운 파일을 잘 가져오는 것을 확인할 수 있다.

slug 및 markdown front 파싱

최종적으로 만들 형태

각 마크다운 파일을 순회하며 front 메타정보와 content를 읽어서 다음과 같은 형태로 만든다.

posts = {
	'/blog/contents/test/test/test1': {
		content: '이것은 테스트 마크다운입니다.'
		front: {
			title: "타이틀"
		    date: 생성시각 Date() 객체, <- 추후 string으로 바꿈
		    category: "문제해결",
	        isPublish: true,
		}
	},
	'/blog/contents/test/style/폰트': {
		content: '폰트는 이러케 저러케 생겼습니다.'
		front: {
			title: "폰트"
		    date: 생성시각 Date() 객체,
		    category: "기능구현",
	        isPublish: false,
		}
	},
	...
  }

slug 생성

// Get slug
mdFiles.forEach((postPath) => {
    // Get slug
    const slug = path
      .join(`${contentPath}`, postPath.slice(fullPath.length))
      .replace(".md", "");

각 파일을 순회하며 /blog/contents/test/~ 이렇게 확장자와 프로젝트를 제외한 경로만 남긴다.

front 파싱

import matter from "gray-matter";

mdFiles.forEach((postPath) => {
	
	...

    // Parsing files
    const file = readFileSync(postPath, { encoding: "utf-8" });
    const { content, data: front } = matter(file);

    // Add Empty values
    const stats = statSync(postPath); // Get metadata
    const fileCreationDate =
      stats.birthtime && stats.birthtime.getTime() !== 0
        ? stats.birthtime
        : stats.mtime;

	// Default value setting
    const newFront: MarkdownFront = {
      title: front.title || postPath.split("/").slice(-1)[0].replace(".md", ""),
      date: front.date || new Date(fileCreationDate),
      category: front.category || "일반",
      lock: front.lock || false,
      isPublish: front.isPublish || false,
    };

    posts[slug] = {
      content,
      front: newFront,
    };
  });

  return posts;
};

마크다운은 gray-matter 라이브러리를 사용해 front-matter를 파싱했다.

front의 경우 계속 수정해서 내용은 다르지만, 이런식으로 newFront에 추가할 때 반드시 기본값을 넣어줘서 빈 값으로 둔다 하더라도 문제가 생기지 않도록 했다. 현재는 thumbnail, lock, recommended, series 등 다양한 값을 추가했다.

데이터 기반 slug 생성

위에서 만든 fetchPosts 함수를 generateStaticParams 내에서 불러와 사용한다.

export async function generateStaticParams() {
  const posts = await fetchPosts("/test");

  const slugs = Object.keys(posts);

  return slugs.map((slug) => {
    const slugPieces = slug
      .replace(".md", "")
      .split("/")
      .filter((el) => Boolean(el));

    return {
      slug: slugPieces,
    };
  });
}

slug는 아래처럼 생성된다.
no image

페이지 내에서 정보 가져오기

각 페이지에서 post 정보는 다음과 같이 불러올 수 있다.


export default async function Page({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  const decodedSlug = slug.map((segment) => decodeURIComponent(segment));

  const path = `/${decodedSlug.join("/")}`;

  const posts = await fetchPosts("/test");

  const { content, front } = posts[path];
  const { title, date, category, lock } = front;

이 때 중요한 것은, slug에 한글이 들어가있어서, 페이지가 생성될 때 인코딩 된 형태로 URL이 만들어진다. 따라서 decode를 통해 원래 한글 형태로 바꿔줘야 한다. 배열로 들어온 slug들을 모아서 posts의 key로 사용하면 된다.

return (
    <div className="w-full flex flex-col items-center">
      <div className="bg-blue-500">{title}</div>
      {/* <div>date: {date}</div> */}
      <div>category: {category}</div>
      <div>lock: {lock}</div>
      <div>created_date: {date.toISOString()}</div>

      {/* <p>content: {content}</p> */}
      <div className="prose">
        <MDXRemote
          source={content}
          options={{
            mdxOptions: {
              remarkPlugins: [remarkGfm],
              format: "md",
            },
          }}
        />
      </div>
    </div>
  );

가져온 데이터를 렌더링 해보면 잘 들어가 있음을 확인할 수 있다. 이 때 테스트를 위해 tailwindcss의 typography 플러그인을 사용해서 기본 디자인을 적용했다.

no image

경로기반 트리구조 자동 생성

Nav에서 slug를 가져와서 출력해보면 다음과 같이 나온다. 테스트를 위해 5개만 해보자

export default async function Nav() {
  const posts = await fetchPosts("/test");
  let slugs = Object.keys(posts).slice(0, 5);

  console.log(slugs);

[
  '/test/test/test1',
  '/test/style/폰트',
  '/test/react/react server component',
  '/test/react/React 타입들',
  '/test/nextjs/참고 사이트'
]

이 5개의 경로를 아래와 같은 트리구조로 만들 것이다.

중간 노드

no image

각 노드는 name, count, children, isLeaf를 가지고 있다.

현재 root 경로를 기반으로 /test 하위에는 총 5개의 마크다운이 있고, "test", "style", "react", "nextjs"라는 4개의 하위 폴더가 있다. 또한 /test 경로는 마지막 경로가 아니다. 따라서 다음과 같은 중간노드를 생성할 수 있다.

  • name: test (현재 폴더 이름)
  • count: 5 (하위 마크다운 개수)
  • children: {test: {}, style: {}, react: {}, nextjs: {}} 하위 폴더 및, 해당하는 노드 객체
  • isLeaf: false

/test/react 폴더의 경우를 살펴보면 하위에 2개의 마크다운이 있고 /test/react/react server component, /test/react/React 타입들 , children으로는 2개의 노드가 있으며, 마지막 경로가 아니기 때문에 다음처럼 쓸 수 있다.

  • name: react
  • count: 2
  • children: {'react server component': {}, 'React 타입들': {}}
  • isLeaf: false

이를 합쳐서 보면

const pathObj = {
	name: 'root'
	count: 5
	children: {
		"test": {
			name: 'test',
			count: 5,
			children: {
				"test": {},
				"style": {},
				"react": {
					name: "react",
					count: 2,
					children: {},
					isLeaf: false
				},
				"nextjs": {}
			},
			isLeaf: false
		}
	},
	isLeaf: false
}

이런 식의 재귀적인 구조가 된다.

잎사귀 노드(마지막 노드)

마지막 노드는 폴더가 아니라 마크다운 파일이기 때문에 몇 가지 정보가 더 추가된다.
no image

leaf node의 count는 1이며, children은 없다.

  • path: 현재 파일에 오기까지의 모든 중간 경로를 합친 full path
  • createdDate: 생성시각

이후 nav를 구성할 때 path 정보를 통해 Link를 생성해주기만 하면 페이지 라우팅이 완성된다.

코드로 보기

const pathTree: TreeObj = {
    name: "root",
    count: 0,
    children: {},
    isLeaf: false,
  };

  slugs.forEach((slug) => {
    const segments = slug.split("/").filter((segment) => Boolean(segment));

    let curObj = pathTree;
    curObj.count += 1;

    segments.forEach((segment, idx) => {
      if (!curObj.children[segment]) {
        curObj.children[segment] = {
          name: segment,
          count: 0,
          children: {},
          isLeaf: false,
        };
      }

      // If leaf node
      if (idx === segments.length - 1) {
        curObj.children[segment].isLeaf = true;
        curObj.children[segment].path = slug;
        curObj.children[segment].createdDate = posts[slug].front.date;
      }

      curObj.children[segment].count += 1;
      curObj = curObj.children[segment];
    });
  });

이렇게 만든 데이터를 Nav 내에서 렌더링 해주는 컴포넌트를 작성해야한다.


return (
    <div className="h-screen w-0 md:w-64 bg-amber-50 overflow-auto">
      <Link href="/blog/published" className="ml-3">
        posts {publishedCount}
      </Link>
      <div className="">
        <TreeItem tree={pathTree} depth={0} isOpen={true} />
      </div>
    </div>
  );

TreeItem에 props로 tree를 넘기면 하위 구조를 렌더링해주는 방식을 사용했다. (root뿐 아니라 어떤 중간 path를 넣어줘도 하위 구조를 재귀적으로 렌더링해준다.)

전체 코드

"use client";

import { cn } from "@/lib/utils";
import React, { useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";

export function TreeItem({
  tree,
  depth,
  isOpen,
}: {
  tree: TreeObj;
  depth: number;
  isOpen: boolean;
}) {
  const { name, count, children, isLeaf, path } = tree;
  const router = useRouter();
  const [open, setOpen] = useState<boolean>(isOpen);

  const navClick = () => {
    if (isLeaf && path) {
      router.push(`/blog${path}`);
    }
  };

  const SubTree = useMemo(() => {
    return Object.values(children)
      .map((node) => {
        const newDepth = depth + 1;
        let isOpen = false;

        if (newDepth <= 1) {
          isOpen = true;
        }

        return (
          <React.Fragment key={node.name}>
            <TreeItem tree={node} depth={depth + 1} isOpen={isOpen} />
          </React.Fragment>
        );
      });
  }, [children]);

  const nodeClick = (e: React.MouseEvent<HTMLDivElement>) => {
    e.stopPropagation();

    setOpen(!open);
  };

  return (
    <div
      className={cn(
        "flex flex-col ml-3 cursor-pointer",
        isLeaf && "cursor-pointer",
      )}
      onClick={nodeClick}
    >
      <div className="flex gap-2" onClick={navClick}>
        <span>{name}</span>
        <span>{count}</span>
      </div>

      <div className={cn(open ? "block" : "hidden")}>{SubTree}</div>
    </div>
  );
}

코드를 보면 leaf 노드일 때는 페이지를 라우팅하며, 아닐 경우에는 SubTree를 렌더링한다.
no image

요런 식으로 테스트를 완료했다. 이렇게 가장 최소한으로 obsidian 트리구조를 만드는 방법을 모두 소개했다.

아쉬운점

현재 각 컴포넌트에서 fetchPosts를 각각 해오고 있는데, 마크다운 파싱이 불필요하게 일어나고 있다. 잘 파싱이 되는지 테스트하기 위한 코드이기 때문에, 추후 fetch의 구조를 완전히 수정해서 빌드 타임에 한번만 fetching하고 데이터를 전부 캐싱해 사용하는 구조를 고안해야 한다.